Skip to content

tinydiffeq 2.3.0: port to nlls-gram 2.7.0 - #8

Merged
jlperla merged 4 commits into
mainfrom
agent/nlls-2.7-port
Jul 26, 2026
Merged

tinydiffeq 2.3.0: port to nlls-gram 2.7.0#8
jlperla merged 4 commits into
mainfrom
agent/nlls-2.7-port

Conversation

@jlperla

@jlperla jlperla commented Jul 26, 2026

Copy link
Copy Markdown
Member

Ports to nlls-gram 2.7.0 and releases 2.3.0.

Why this is required

nlls-gram 2.6/2.7 collapsed its flat constructor kwargs into typed config
objects. Of the 26 kwargs LMRootSolver mirrored and forwarded, 17 no
longer exist
— so published tinydiffeq 2.2.0 breaks at solver construction
against nlls-gram 2.7.0 (2.6.0 still accepted them).

LMRootSolver: 26 fields to 6

Keeps only the five this package owns, all of which reach the nlls solve
rather than its constructor: max_steps, max_steps_is_success, atol,
gtol, xtol. The 21 mirrored algorithm knobs are replaced by one
solver_options pass-through forwarded verbatim to LevenbergMarquardt, so
the names track nlls-gram across versions instead of being duplicated here.

It normalizes to a sorted tuple: _cached_algebraic_solver is
functools.cached on the config and falls back to rebuilding on TypeError,
and nlls-gram keys its compiled loop on solver identity — an unhashable config
would silently retrace every step. cache_jacobian and
geodesic_acceleration stay pinned to False and are rejected by name rather
than silently honored, since each DAE stage changes the root problem.

float32 and GPU

tests/conftest.py pins jax_default_matmul_precision=highest: XLA:GPU
serves float32 dot_general from TF32 tensor cores at a 10-bit mantissa, and
the matmul-heavy exponential and Markov paths are checked against closed forms
at float32-eps tolerances.

Four float32 GPU tests added. The existing ones build state with a bare
jnp.asarray(1.0), which under the x64 conftest is float64 — so the core ODE,
SDE and DAE GPU paths had no single-precision coverage at all. One pins the
z_dtype-only root_atol rule.

Verification

  • CPU, against the published nlls-gram 2.7.0 wheel: 251 passed, 15 skipped
  • GPU (RTX 3090): 266 passed, 0 failed
  • Accuracy unchanged by the ad_solver default move, including the 1e-12
    implicit-tangent cases
  • Benchmarks: the DAE reverse-mode regression that motivated the LU fix
    upstream is closed (vjp-vector16-dae +18.4% to -1.6%)

jlperla and others added 4 commits July 25, 2026 15:04
…s-through

nlls-gram 2.6/2.7 collapsed its flat constructor kwargs into solver and
preconditioner objects: of the 26 kwargs LMRootSolver mirrored and forwarded,
17 no longer exist. Rather than re-mirror the new surface, LMRootSolver now
keeps only the five fields this package actually owns -- max_steps,
max_steps_is_success, atol, gtol, xtol, all of which reach the nlls solve
rather than its constructor -- and runs everything algorithmic at nlls-gram's
defaults.

solver_options replaces the 21 dropped knobs with one pass-through forwarded
verbatim to the LevenbergMarquardt constructor, so the names track nlls-gram
across versions instead of being duplicated here. It normalizes to a sorted
tuple: _cached_algebraic_solver is functools.cache'd on the config and falls
back to rebuilding on TypeError, and nlls-gram keys its compiled loop on
solver identity, so an unhashable config would silently retrace every step.
cache_jacobian and geodesic_acceleration stay fixed to False and are rejected
by name -- each DAE stage changes the root problem -- rather than being
silently honored.

Verified against the nlls-gram 2.4.0 baseline on this machine: 251 passed /
11 skipped (the extra test is the new normalization case) against 250 / 11,
same 129s. Accuracy is unaffected by ad_solver "auto" -> None, including the
1e-12 implicit-tangent tests.

Benchmarks (115 cases) are flat in aggregate, median -0.04%, but that hides a
real split on the nlls path, reproducible across two runs: reverse mode is
slower (vjp-vector16-dae +18/+22%, vjp-tree16-dae +17/+18%, vjp-scalar-dae
+9/+11%, vjp-vector16-sdae +9/+11%) and forward mode faster (jvp-*-dae/sdae
-8 to -17%). Every rodas5p_dae case is flat, which fits: Rodas5P uses the root
solver only for initial consistency. Probing ad_solver rules out the config as
the cause -- default (636us vjp) already beats Cholesky (613us, within noise),
SVD (874us) and QR (680us) -- so this is 2.7.0's implicit-AD path, reported
rather than worked around.

Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
The GPU tests build state with a bare `jnp.asarray(1.0)`, which under the x64
conftest is float64 -- so the core ODE, SDE and DAE GPU paths had no
single-precision coverage at all. Four float32-end-to-end tests close that, at
a deliberately coarse bar: no crash, on device, finite, and correct to a few
digits. Tight float32 agreement is a CPU concern; the point here is that the
GPU kernels take the same paths and do not return nonsense.

One of them pins the dtype rule in dae.py, which reads z_dtype ALONE to pick
root_atol (1e-10 above 32 bits, else 1e-6): a float32 y with a float64 z gets
the tight bar, and both dtypes have to survive the round trip on device.

conftest also sets jax_default_matmul_precision=highest. XLA:GPU serves
float32 dot_general from TF32 tensor cores by default at a 10-bit mantissa,
and the float32 tests here check against closed forms with tolerances derived
from float32 eps -- the linear-exponential and Markov paths are matmul-heavy
enough that TF32 would fail them on GPU while passing on CPU. nlls-gram pins
the products it owns, but it cannot reach the matmuls in a caller's vector
field, which is exactly what these tests exercise. A no-op on CPU.

Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
The two new float32 GPU tests read .xs[-1]. SaveAt defaults to t_1=True, so
xs is already the endpoint -- a scalar here, and IndexError on a GPU box. The
DAE cases passed only because their state is a 1-element array. Caught by the
3090 run, which is the point of having them.

Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Floors nlls-gram at the released 2.7.0 and drops the local editable source
override, so the published wheel resolves from PyPI instead of carrying a
path into a checkout.

2.2.0 and earlier are not compatible with nlls-gram 2.7.0: LMRootSolver
forwarded 26 constructor kwargs and 17 of them no longer exist, so a fresh
install of 2.2.0 against 2.7.0 fails at solver construction. 2.6.0 still
accepted them, which is why the break starts now.

LMRootSolver went from 26 fields to 6 in that port: the five this package
actually owns, which reach the nlls solve rather than its constructor, plus a
solver_options pass-through for the rare root that needs a non-default
algorithm. Verified against the published wheel: 251 passed, 15 skipped.

Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
@jlperla
jlperla merged commit cbe3e32 into main Jul 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant